Unity: Refactoring without breaking serialisation

Apr 28, 2023 · 4 mins read
Unity: Refactoring without breaking serialisation

How to Refactor code without breaking serialisation

Today I’ll take a look at a problem I recently encountered when I wanted to refactor some code in my Unity Volume Rendering plugin.

The problem

I had the following code:

[Serializable]
public class VolumeDataset : ScriptableObject
{
    public float scaleX = 1.0f;
    public float scaleY = 1.0f;
    public float scaleZ = 1.0f;
}

An instance of this class is referenced by another component, that could potentially be serialised and saved as a prefab or as part of a scene.

I wanted to replace these three floats with a Vector3, like this:

[SerializeField]
public Vector3 scale = Vector3.one;

However, there is one problem. If I do this change, any previously serialised instances of this class (stored in a scene or prefab) will lose their saved scale values, and not copied over to the new scale variable.

So how can we do this refactoring without breaking existing prefabs and scenes?

The solution

If we were simply re-naming a variable, we could use FormerlySerializedAs and it would just work (unity would be able to recognise that the variable has changed names, and prevent loss of data on load).

However, in our case we’re replacing three variables of type float with one Vector3. I couldn’t find a perfect solution for this, but this is what I decided to do:

  • Create a new scale variable of type Vector3
  • Make scaleX,scaleY and scaleZ private, and keep them around for some months/releases to give users of the library some time to update.
  • Write custom code that copies the values of scaleX/scaleY/scaleZ over to the new scale vector on deserialisation, and does the opposite on serialisation.

Here’s the code:

[SerializeField]
public Vector3 scale = Vector3.one;

[SerializeField, System.Obsolete("Use scale instead")]
private float scaleX = 1.0f;
[SerializeField, System.Obsolete("Use scale instead")]
private float scaleY = 1.0f;
[SerializeField, System.Obsolete("Use scale instead")]
private float scaleZ = 1.0f;

public void OnBeforeSerialize()
{
    scaleX = scale.x;
    scaleY = scale.y;
    scaleZ = scale.z;
}

public void OnAfterDeserialize()
{
    scale = new Vector3(scaleX, scaleY, scaleZ);
    Debug.Log(scale);
}

Whenever someone loads a previously serialised instance (when loading a scene, etc.), the old scale values will be copied over to the new vector. If I keep the old scale variables around for some time, this should give users of my plugin some time to update their prefabs/scenes, so that it won’t break anything when I eventually remove them - maybe in a year or so.

An improved solution

We just made three public variables now be private. This might be an unpleasant surprise to people using them, as they will get compilation errors when the update our library. We could instead deprecate them, using System.Obsolete. However, then we’d get warnings whenever we access them in OnBeforeSerialize and OnAfterDeserialize. So a better solution would be to make them private, re-name them to something else and use FormerlySerializedAs to make sure old values are copied over. And then we can create a public property to replace the old public variables, which we can then safely deprecate using System.Obsolete.

[SerializeField]
public Vector3 scale = Vector3.one;

[SerializeField, FormerlySerializedAs("scaleX")]
private float scaleX_deprecated = 1.0f;
[SerializeField, FormerlySerializedAs("scaleY")]
private float scaleY_deprecated = 1.0f;
[SerializeField, FormerlySerializedAs("scaleZ")]
private float scaleZ_deprecated = 1.0f;

[System.Obsolete("Use scale instead")]
public float scaleX { get { return scale.x; } set { scale.x = value; } }
[System.Obsolete("Use scale instead")]
public float scaleY { get { return scale.y; } set { scale.y = value; } }
[System.Obsolete("Use scale instead")]
public float scaleZ { get { return scale.z; } set { scale.z = value; } }

public void OnBeforeSerialize()
{
    scaleX = scale.x;
    scaleY = scale.y;
    scaleZ = scale.z;
}

public void OnAfterDeserialize()
{
    scale = new Vector3(scaleX, scaleY, scaleZ);
    Debug.Log(scale);
}

Here we replace the three scale variables with three new public deprecated properties, that return or sets the value of our new scale vector. The purpose of these is to make sure users who were accessing the scaleX/Y/Z variables won’t get any compilation errors after upgrading. Instead, they will now get a warning saying that these variables are deprecated.

To prevent loss of data, we also define three new private float variables where we use the FormerlySerializedAs attribute to make sure the data is copied over from the old scale variables. Then on load, we copy these values over to the new scale vector. So the purpose of these three new floats is simply to work as an intermediate for copying over the old values on load. Later, when users of my library have been giver some time to load and save their scenes/prefabs, I can remove all three, and also the

Sharing is caring!


Comments

You can use your Mastodon account to reply to this post.

Reply