Sérialisation Unity

Vous écrivez donc une extension d’éditeur vraiment cool dans Unity et les choses semblent très bien se passer. Vous avez toutes vos structures de données triées et vous êtes vraiment satisfait du fonctionnement de l'outil que vous avez écrit.
Ensuite, vous entrez et sortez du mode de jeu.
Soudain, toutes les données que vous aviez saisies disparaissent et votre outil est réinitialisé à l'état par défaut, simplement initialisé. C'est très frustrant ! « Pourquoi cela arrive-t-il ? », vous demandez-vous. La raison est liée au fonctionnement de la couche gérée (mono) d’ Unity . Une fois que vous l'avez compris, les choses deviennent beaucoup plus faciles :)
Que se passe-t-il lorsqu’un assemblage est rechargé ?
Lorsque vous entrez/sortez du mode de lecture ou modifiez un script, Unity doit recharger les assemblys mono, c'est-à-dire les DLL associées à Unity.
Du côté de l'utilisateur, il s'agit d'un processus en 3 étapes :
- Extrayez toutes les données sérialisables du territoire géré, en créant une représentation interne des données du côté C++ d' Unity.
- Détruisez toute la mémoire/information associée au côté géré d’ Unity et rechargez les assemblys.
- Resérialisez les données enregistrées en C++ dans un espace géré.
Cela signifie que pour que vos structures de données/informations survivent à un rechargement d'assemblage, vous devez vous assurer qu'elles peuvent être sérialisées correctement dans et hors de la mémoire C++. Cela signifie également que (avec quelques modifications mineures) vous pouvez enregistrer cette structure de données dans un fichier d'actifs et la recharger ultérieurement.
Comment travailler avec la sérialisation d'Unity ?
La manière la plus simple d’en apprendre davantage sur la sérialisation Unity est de travailler sur un exemple. Nous allons commencer avec une fenêtre d'éditeur simple, elle contient une référence à une classe que nous voulons faire survivre à un rechargement d'assemblage.
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 ();
}
}Lorsque vous exécutez ceci et forcez un rechargement d'assemblage, vous remarquerez que toute valeur dans la fenêtre que vous avez modifiée ne survivra pas. C'est parce que lorsque l'assemblage est rechargé, la référence à « m_SerialziedThing » disparaît. Il n'est pas balisé pour être sérialisé.
Il y a quelques choses à faire pour que cette sérialisation fonctionne correctement :
Dans MyWindow.cs :
- Le champ « m_SerializedThing » doit avoir l’attribut [SerializeField] ajouté. Cela indique à Unity qu'il doit tenter de sérialiser ce champ lors du rechargement de l'assembly ou d'événements similaires.
Dans SerializeMe.cs :
- La classe « SerializeMe » doit avoir l'attribut [Serializable] ajouté. Cela indique à Unity que la classe est sérialisable.
- La structure 'NestedStruct' doit avoir l'attribut [Serializable] ajouté.
- Chaque champ (non public) que vous souhaitez sérialiser doit avoir l'attribut [SerializeField] ajouté.
Après avoir ajouté ces indicateurs, ouvrez la fenêtre et modifiez les champs. Vous remarquerez qu'après un rechargement d'assemblage, les champs conservent leurs valeurs ; à l'exception du champ provenant de la structure. Cela soulève le premier point important : les structures ne sont pas très bien prises en charge pour la sérialisation. Le changement de « NestedStruct » d'une structure à une classe résout ce problème.
Le code ressemble maintenant à ceci :
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 ();
}
}Quelques règles de sérialisation
- Éviter les structures
- Les classes que vous souhaitez rendre sérialisables doivent être marquées avec [Serializable]
- Les champs publics sont sérialisés (à condition qu'ils fassent référence à une classe [Serializable])
- Les champs privés sont sérialisés dans certaines circonstances (éditeur).
- Marquez les champs privés comme [SerializeField] si vous souhaitez qu'ils soient sérialisés.
- [NonSerialized] existe pour les champs que vous ne souhaitez pas sérialiser.
Objets scriptables
Jusqu’à présent, nous avons examiné l’utilisation de classes normales en matière de sérialisation. Malheureusement, l'utilisation de classes simples pose certains problèmes en matière de sérialisation dans Unity. Prenons un exemple.
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();
}
}
Il s'agit d'un exemple artificiel pour montrer un cas particulier du système de sérialisation Unity qui peut vous surprendre si vous ne faites pas attention. Vous remarquerez que nous avons deux champs de type NestedClass. La première fois que la fenêtre est dessinée, elle affichera les deux champs, et comme m_Class1 et m_Class2 pointent vers la même référence, la modification de l'un modifiera l'autre.
Essayez maintenant de recharger l'assemblage en entrant et en sortant du mode de lecture... Les références ont été découplées. Cela est dû à la façon dont fonctionne la sérialisation lorsque vous marquez une classe comme simplement [Sérialisable]
Lorsque vous sérialisez des classes standard, Unity parcourt les champs de la classe et sérialise chacun d'eux individuellement, même si la référence est partagée entre plusieurs champs. Cela signifie que vous pourriez avoir le même objet sérialisé plusieurs fois, et lors de la désérialisation, le système ne saura pas qu'il s'agit réellement du même objet. Si vous concevez un système complexe, cela constitue une limitation frustrante, car cela signifie que les interactions complexes entre les classes ne peuvent pas être capturées correctement.
Entrez ScriptableObjects ! Les ScriptableObjects sont un type de classe qui se sérialise correctement en tant que références, de sorte qu'ils ne sont sérialisés qu'une seule fois. Cela permet de stocker les interactions de classe complexes de la manière à laquelle vous vous attendez. En interne dans Unity, les ScriptableObjects et les MonoBehaviour sont les mêmes ; dans le code utilisateur, vous pouvez avoir un ScriptableObject qui n'est pas attaché à un GameObject ; c'est différent du fonctionnement de MonoBehaviour . Ils sont parfaits pour la sérialisation des structures de données générales.
Modifions l'exemple pour pouvoir gérer correctement la sérialisation :
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();
}
}Les trois changements à noter ici sont les suivants :
- NestedClass est désormais un ScriptableObject.
- Nous créons une instance en utilisant la fonction CreateInstance<> au lieu d'appeler le constructeur.
- Nous avons également défini les indicateurs de masquage... cela sera expliqué plus tard
Ces changements simples signifient que l'instance de NestedClass ne sera sérialisée qu'une seule fois, chacune des références à la classe pointant vers la même.
Initialisation de ScriptableObject
Nous savons maintenant que pour les structures de données complexes où un référencement externe est nécessaire, il est judicieux d'utiliser des ScriptableObjects. Mais quelle est la bonne façon de travailler avec des ScriptableObjects à partir du code utilisateur ? La première chose à examiner est COMMENT les objets scriptables sont initialisés, en particulier à partir du système de sérialisation Unity .
Le constructeur est appelé sur le ScriptableObject.
Les données sont sérialisées dans l'objet du côté C++ d'Unit (si de telles données existent).
OnEnable() est appelé sur le ScriptableObject.
En travaillant avec ces connaissances, nous pouvons dire certaines choses :
- Effectuer l'initialisation dans le constructeur n'est pas une très bonne idée car les données seront potentiellement remplacées par le système de sérialisation.
- La sérialisation se produit APRÈS la construction, nous devons donc effectuer notre configuration après la sérialisation.
- OnEnable() semble être le meilleur candidat pour l'initialisation.
Apportons quelques modifications à la classe « SerializeMe » afin qu’elle soit un ScriptableObject. Cela nous permettra de voir le modèle d'initialisation correct pour les 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();
}
}
À première vue, il semble que nous n'ayons pas vraiment changé cette classe, elle hérite maintenant de ScriptableObject et au lieu d'utiliser un constructeur, elle a un OnEnable(). La partie importante à noter est légèrement plus subtile... OnEnable() est appelé APRÈS la sérialisation ; grâce à cela, nous pouvons voir si les [SerializedFields] sont null ou non. Si elles sont null , cela indique qu'il s'agit de la première initialisation et que nous devons construire les instances. S'ils ne sont pas null , ils ont été chargés en mémoire et n'ont PAS besoin d'être construits. Il est courant dans OnEnable() d'appeler également une fonction d'initialisation personnalisée pour configurer tous les champs privés / non sérialisés sur l'objet, un peu comme vous le feriez dans un constructeur.
Masquer les drapeaux
Dans les exemples utilisant ScriptableObjects, vous remarquerez que nous définissons les « hideFlags » sur l'objet sur HideFlags.HideAndDontSave. Il s'agit d'une configuration spéciale requise lors de l'écriture de structures de données personnalisées qui n'ont pas de racine dans la scène. Ceci permet de contourner le fonctionnement du chargement de scène dans Unity.
Lorsqu'une scène est chargée en interne, Unity appelle Resources.UnloadUnusedAssets. Si rien ne fait référence à un actif, le ramasse-miettes le trouvera. Le GC utilise la scène comme « racine » et parcourt la hiérarchie pour voir ce qui peut être GC. La définition de l'indicateur HideAndDontSave sur un ScriptableObject indique à Unity de considérer cet objet comme un objet racine. Pour cette raison, il ne disparaîtra pas simplement à cause d'un rechargement de l'assemblage. L'objet peut toujours être détruit en appelant Destroy().
Quelques règles de ScriptableObject
- Les ScriptableObjects ne seront sérialisés qu'une seule fois, vous permettant d'utiliser correctement les références.
- Utilisez OnEnable pour initialiser les ScriptableObjects.
- N'appelez jamais le constructeur d'un ScriptableObject, utilisez plutôt CreatInstance
- Pour les structures de données imbriquées qui ne sont référencées qu'une seule fois, n'utilisez pas ScriptableObject car elles ont plus de surcharge.
- Si votre objet scriptable n'est pas enraciné dans la scène, définissez hideFlags sur HideAndDontSave.
Sérialisation de tableau concret
Jetons un œil à un exemple simple qui sérialise une gamme de classes concrètes.
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 ());
}
}Cet exemple de base contient une liste de BaseClasses. En cliquant sur le bouton « Ajouter simple », il crée une instance et l'ajoute à la liste. Étant donné que la classe SerializeMe est correctement configurée pour la sérialisation (comme indiqué précédemment), elle « fonctionne tout simplement ». Unity voit que la liste est marquée pour la sérialisation et sérialise chacun des éléments de la liste.
Sérialisation générale des tableaux
Modifions l’exemple pour sérialiser une liste qui contient les membres d’une classe de base et d’une classe enfant :
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 ());
}
}
L'exemple a été étendu de sorte qu'il existe désormais une ChildClass, mais nous sérialisons à l'aide de la BaseClass. Si vous créez quelques instances de la ChildClass et de la BaseClass, elles s'afficheront correctement. Des problèmes surviennent lorsqu'ils sont placés via un rechargement d'assemblage. Une fois le rechargement terminé, chaque instance sera une BaseClass, avec toutes les informations ChildClass supprimées. Les instances sont cisaillées par le système de sérialisation.
La façon de contourner cette limitation du système de sérialisation est d'utiliser à nouveau des 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());
}
}
Après avoir exécuté ceci, modifié certaines valeurs et rechargé les assemblys, vous remarquerez que les ScriptableObjects peuvent être utilisés en toute sécurité dans les tableaux, même si vous sérialisez des types dérivés. La raison est que lorsque vous sérialisez une classe [Serializable] standard, elle est sérialisée « sur place », mais un ScriptableObject est sérialisé en externe et la référence insérée dans la collection. Le cisaillement se produit parce que le type ne peut pas être correctement sérialisé car le système de sérialisation pense qu'il s'agit du type de base.
Sérialisation des classes abstraites
Nous avons donc maintenant vu qu'il est possible de sérialiser une liste générale (à condition que les membres soient de type ScriptableObject). Voyons comment se comportent les classes abstraites :
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());
}
}
Ce code fonctionne, tout comme l'exemple précédent. Mais c'est dangereux. Voyons pourquoi.
La fonction CreateInstance<>() attend un type qui hérite de ScriptableObject, la classe 'MyBaseClass' hérite en effet de ScriptableObject. Cela signifie qu'il est possible d'ajouter une instance de la classe abstraite MyBaseClass au tableau m_Instances. Si vous faites cela et essayez ensuite d’accéder à une méthode abstraite, de mauvaises choses se produiront car il n’y a pas d’implémentation de cette fonction. Dans ce cas précis, ce serait la méthode OnGUI.
L'utilisation de classes abstraites comme type sérialisé pour les listes et les champs fonctionne, à condition qu'elles héritent de ScriptableObject, mais ce n'est pas une pratique recommandée. Personnellement, je pense qu'il est préférable d'utiliser des classes concrètes avec des méthodes virtuelles vides. Cela garantit que les choses ne se passeront pas mal pour vous.
Quand les ScriptableObjects sont-ils conservés dans les fichiers de scène/préfabriqués ?
Les GameObjects et leurs composants sont enregistrés dans une scène par défaut. Les types d'actifs (matériaux / maillages / animationclip / objets sérialisés) créés à partir du code sont enregistrés dans la scène tant qu'un objet de jeu ou ses composants dans la scène y font référence.
Les types d’actifs peuvent également être explicitement marqués comme actifs à l’aide de AssetDatabase.CreateAsset. Dans ce cas, ils ne seront pas enregistrés dans la scène mais simplement référencés. Si un type d'actif ou un type d'objet de jeu est marqué comme HideAndDontSave, il n'est pas non plus enregistré dans la scène.
Des questions ?
