singleton

Dans cet article on va aborder l’implémentation de singletons avec des ScriptablesObjects.

Edit 2022 : Après avoir pu utiliser quelques scriptable singletons ici et là dans différents projets, j’ai décidé de mettre à jour les snippets de code et vous donner plus d’exemple en fin d’article sur de possibles cas d’usage. Bonne lecture.

C’est quoi ce Frankenstein ?

Avouons le, les singletons c’est quand même bien pratique ! Malheureusement, quand c’est mal implémenté ça devient vite l’enfer. Et si je vous dit qu’en mélangeant les avantages des ScriptablesObjects et du Singleton Pattern, on peut s’en sortir gagnant ?

Un mot sur les singletons

L’utilisation ou non des singletons comme une bonne pratique est un sujet houleux et sans cesse débattu au sein de la communauté. Je ferais peut être un article sur le sujet un jour.

Malgré cela, avoir un accès global et rapide à un système ou un composant d’une application est souvent une nécessité, et on fini par se rabattre sur cette solution facile à mettre en place dans un premier temps (spoiler : c’est un piège sur le long terme).

C’est un Monobehaviour Jim, mais en différent

Un ScriptableObject n’est pas lié à un GameObject mais est stocké en tant qu’asset dans le projet ou alors instancié comme n’importe quelle classe C#, c’est à dire qu’il flotte en mémoire. Pas besoin donc de s’occuper de gérer une instance dans la scène, qui n’a franchement aucune raison d’exister de toute façon. (créer des références d’objets dans la scène sur un singleton c’est une satanerie, arrêtez ça tout de suite.)

En plus, le cycle de vie d’un ScriptableObject est simple : il est chargé en mémoire lorsqu’une référence est présente dans la scène, et il est collecté puis libéré par le Garbage Collector une fois qu’aucune référence n’est présente.

J’en entend encore au fond qui disent :
“Ouais c’est bien ton truc, mais avec un ScriptableObject on peut pas récupérer de référence en dehors de l’inspecteur !"

Et bien c’est pour ça qu’on va améliorer notre ScriptableObject en singleton mon brave ! De cette manière, n’importe quelle classe pourra y accèder, pas seulement les classes sérializables dans l’inspecteur.

Le code !

Afin de se faciliter la vie, et de respecter la règle du D.R.Y (Don’t Repeat Yourself), on va se faire une petite classe de base générique. Vous inquiétez pas, on verra une implémentation concrète après.

using System;
using System.Linq;
using UnityEngine;

public abstract class ScriptableObjectSingleton<T> : ScriptableObject
  where T : ScriptableObjectSingleton<T>
{
    private static T _instance;

    public static T Instance
    {
        get
        {
            if (!_instance) _instance = Resources.LoadAll<T>("").FirstOrDefault();
            if (!_instance) throw new Exception($"Cannot find instance of {typeof(T)} in Resources.");
            if(_instance) _instance.hideFlags = HideFlags.DontUnloadUnusedAsset;
            return _instance;
        }
    }
}

Avouez, ça ressemble quand même à un singleton tout ce qu’il y a de plus normal !

💡 Note : cette classe retournera uniquement la première instance présente dans le projet, elle ne créera pas une instance sur demande.

Encore un détail, vous vous souvenez du cycle de vie d’un ScriptableObject ? Et bien pour éviter qu’il ne soit collecté par le Garbage Collector et que l’on perde des données, on va passer son HideFlag sur DontUnloadUnusedAsset. De cette façon on évite que l’instance ne soit collectée et libérée de la mémoire des le moment ou aucune référence n’est présente dans la scène.

Maintenant il nous faut une implémentation concrète, ici on va prendre un inventaire de joueur comme exemple :

using UnityEngine;

[CreateAssetMenu(menuName = "Inventory")]
public class Inventory : ScriptableObjectSingleton<Inventory>
{
    public int NumberOfItems;
    public string PlayerName;
}

A partir d’ici, c’est comme avec n’importe quel ScriptableObject, avec un clic droit dans la fenêtre de projet, on créé une nouvelle instance de l’inventaire, que l’on peut ensuite inspecter :

Inventory

Pour utiliser notre singleton, encore une fois rien ne change, on peut y accéder en récupérant l’instance directement ou en stockant la référence dans une variable :

void Start()
{
    Debug.Log(Inventory.Instance.NumberOfItems);
}

Au final, à quoi ça sert ?

Et bien de la même manière qu’un singleton traditionnel, on s’en servira pour créer des objets qui n’ont pas de représentation “physique”, comme un manager de sons, un manager d’état ou tout autre fonctionnalité qui n’a pas intrinsèquement besoin d’être un GameObject, mais dont l’accès global est crucial.

Un autre usage très pratique et sous-estimé c’est la création de fichiers de configuration, ou plutôt d’assets de configuration dans le cas précis. On peut alors sérialiser tout ce qu’on veut : champs de textes basiques, références de prefabs, références d’autres ScriptableObjects, etc. On a donc un accès global et rapide à nos configurations, pratique !