Unity Editor Adventures: Serialized Matryoshka

Short introduction


As a rule, in order to get to the field of a serialized property of interest to us, manual therapy advises us to use the FindPropertyRelative () method, into which the variable name is thrown.

For certain reasons, this approach is not always convenient. The reasons can be very diverse. For example, the name of a variable can change, we need nosebleed access to a non-serialized property, we need to have access to getter-setters or even methods of a serialized object. We will not ask the questions “why do you need this at all” and “why you could not do without the traditional ways”. Suppose we need - and that’s it.

So, let's figure out how to get the object we are working with, as well as all its parent objects from the serialized property, and not get caught on the serialization road full of pitfalls.

Attention. This article assumes that you already know how to work with UnityEditor, at least once wrote custom PropertyDrawers, and at least in general terms understand how a serialized property differs from a serialized object.


Serialization path


To begin with, put all the dots over O.

In the most primitive case, we have a certain heir class from MonoBehaviour, and it has a certain field belonging to a serialized class, obviously not an heir from the sacred unitary cow of A.K.A. UnityEngine.Object.

public class ExamplePulsar : MonoBehaviour
{
...
        [Space][Header("Example Sandbox Inspector Field")]
        public SandboxField sandboxField;
...
}

In the code above, SandboxField is a class with the Serializable attribute.

To get access to the owner's MonoBehaviour is not a problem:

UnityEngine.Object serializationRoot = property.serializedObject.targetObject;

If you wish, you can take it through as, but now we do not need it. We are interested in the serialized field itself in order to draw it with all blackjack as in the figure below.


We can take the serialization path as follows:

string serializationPath = property.propertyPath;

In our case, the serialization path will consist of one of our fields and will return “sandboxField”, from which we are neither cold nor hot, since for the first level of nesting we need to know only the name of the variable (which, incidentally, was returned to us).

Please note that there is no parent MonoBehaviour on the way. Now it doesn’t matter, but it will become important when we begin to disassemble a Russian doll that looks something like this:
nestedClassVariable.exampleSandboxesList.Array.data [0] .evenMoreNested.Array.data [0]
In order not to get caught later, when the properties are nested, ahead of time we will do the following:

string[] path = property.propertyPath.Split('.');

Now we have all the nodes of the serialization path. But in the most primitive case, we need only the zero node. Take it:

string pathNode = path[0];

Turn on a little reflection and get the field from here:

Type objectType = serializationRoot.GetType();
FieldInfo objectFieldInfo = objectType.GetField(pathNode);
object field = objectFieldInfo.GetValue(serializationRoot);

We leave behind the scenes the question of the speed of this venture. For a small number of such fields and a small nesting, the cost of reflection will be significantly less than everything that happens under the hood of UnityEditor during the rendering. If you want proofs - on a github, Unity developers have such an interesting thing, UnityCsReference, look at your leisure, how ObjectField rendering is implemented, for example.

Actually, this is not (ha ha) everything. We got the field, we are happy, we can do whatever we want with it and even try to write our own UnityEvent with all the buttons and important actions that affect only our field, no matter what object it hangs on.

At least, while it hangs at the root of this object, everything will be fine, but then it’s not so much anymore. Down the path of serialization, we are waiting for arrays and all kinds of lists, whose main desire is to put us in slippers, timely changing the number of elements upwards. But to hell with this, we would first dig under the array itself.

Our goal is resistance to such dolls

If we didn’t have arrays in the serialization path, the task would be trivial: we would loop through the serialization nodes until we reach the end of the chain. Something like the following code:

object currentObject = serializationRoot;
for (int i = 0; i < directSearchDepth; i++)
{
       string pathNode = path[i];
       Type objectType = currentObject.GetType();
       FieldInfo objectFieldInfo = objectType.GetField(pathNode);
       object nextObject = objectFieldInfo.GetValue(currentObject);
       currentObject = nextObject;
}
return currentObject;

Here we are waiting for two unpleasant news at once. I'll start with the second one.

The step of taking nextObject may suddenly return null instead of the expected object. Usually this happens when we first create the parent object in the inspector, and the serialization path already exists, but the corresponding field does not (this will bring us even more amenities).

In this case, it would be nice to immediately add the exit from the method with the return null:

object nextObject = objectFieldInfo.GetValue(currentObject);
if (nextObject == null) return null;
currentObject = nextObject;

“Wait a minute! - you say. “And what then to do in OnGUI if zero was returned to us?”

Answer: nothing. Nothing literally. Just make a return and thus skip this drawing step, waiting for the field to be created. Nothing terrible will happen from this.

public override void OnGUI(Rect position, SerializedProperty property, GUIContent label)
{
       SandboxField sandboxField = GetTarget<T>(property);
       if (sandboxField == null) return;
}

here GetTarget () is the corresponding function, taking the serialized object from the property.

By the way, I will advise you to take the field of interest to us not here, but in GetPropertyHeight. This will be needed in case we write collapsible-expandable fields with different sizes depending on the content. GetPropertyHeight () is called before OnGUI (), so if we take the field there and write it in the field of our PropertyDrawer, we will not have to take it again to OnGUI.

Please note that an instance of the custom PropertyDrawer is created alone to draw all currently visible serialized properties, and new properties are thrown at it in turn from top to bottom. This should be taken into account so as not to mess with the calculation of the height of the next property, otherwise you may get an unpleasant situation when you click on foldout and the field that you expect is expanded.

Also, all the tinsel that is responsible for displaying the field in the editor and which you want to serialize, you should serialize on the side of the serializable class, not PropertyDrawer, and for the sake of accuracy - enclose conditional compilation brackets so that all this Spanish shame does not try to go to the build :

[Serializable]
public class SandboxField
{
#if UNITY_EDITOR
        public bool editorFoldout;
#endif
}

Another pitfall that awaits us here: all the fields created through the editor wanted to spit on the class constructor and on the default values ​​specified in the class. If you do, for example, like this (example from my project, where it was the value of the nodes of the water surface):

[SerializeField]private int m_nodesPerUnit = 5;

This value will be ignored point-blank serialization as soon as you add a new item to the list. Calling the constructor for help is no less useless: everything that you wrote there will be ignored. The new object is a goal like a falcon, all its values ​​are really the default values, that's just not the ones you want to see there, but all sorts of null, false, 0, Color.clear and other obscene things.

Crutch in meat
. NonUnitySerializableClass, . , DefaultEditorObject(), .

- :

public abstract class NonUnitySerializableClass
{
        protected virtual void DefaultEditorObject()
        {
            // virtually do nothing
        }

        [SerializeField]private bool validated = false;
        public void EditorCreated(bool force = false)
        {
            if (validated && !force) return;
            DefaultEditorObject();
            validated = true;
        }

        public NonUnitySerializableClass()
        {
            EditorCreated(true);
        }
}

DefaultEditorObject(), , EditorCreated .

: .



Let's get back to our sheep, or rather, to the arrays. Another problem that may arise at an even earlier stage lies in this line:

FieldInfo objectFieldInfo = objectType.GetField(pathNode);

The right-hand side can return us zero if it comes up against an array in the serialization path (and any “IList” object will be the “Array” array). Unpleasant.

What to do? Try not to fall into such situations by writing a handler:

for (int i = 0; i < pathNode.Length; i++)
{
        string pathNode = path[i];
        Type objectType = currentObject.GetType();
        FieldInfo objectFieldInfo = objectType.GetField(pathNode);

        if (objectFieldInfo == null)
        {
                if (pathNode == "Array")
                {
                        i++;
                        string nextNode = path[i];
                        string idxstr = nextNode.Substring(nextNode.IndexOf("[") + 1);
                        idxstr = idxstr.Replace("]", "");
                        int arrayNumber = Convert.ToInt32(idxstr);
                        IList collection = currentObject as IList;
                        if (collection.Count <= arrayNumber) return null;
                        currentObject = collection[arrayNumber];
                }
                else
                {
                        throw new NotImplementedException("   ");
                }
        }
        else //  ,     
        {
                object nextObject = objectFieldInfo.GetValue(currentObject);
                if (nextObject == null) return null;
                currentObject = nextObject;
        }
}
return currentObject;

Yes, we can even get into an unpleasant situation here, when the serialization path already has, for example, the data [0] or data [1] element, and the array has not yet implemented it. For example, we created an empty List. We ask him N elements - and without this beautiful line:

if (collection.Count <= arrayNumber) return null;

... we get a bunch of exceptions in purrs. And all that was needed was to skip the rendering step, having waited until the fields of interest to us were created.

I have not yet come across other cases when objectFieldInfo == null, but at the same time the serialization node is not designated as Array, so throwing a terrible exception into such a hypothetical exceptional situation is to subsequently crack it up.

In general, we got a more or less working function that allows us to extract a field by its serialized property. In the future, this function can be modified by forcing to extract all objects in the serialization path, as well as search for the nearest “parent”, including or excluding arrays along the path.

Life hack to draw nested properties
- , Rect position Rect indentedPosition = EditorGUI.IndentedRect(position). , EditorGUI, position , GUI – indentedPosition. EditorGUILayout OnGUI, ( , ).

, MonoScript ( -, ), static-, AssetDatabase, .

Thank you for the attention.

All Articles