Unity and Refactoring

I use IntelliJ’s “Rider” for C# development in Unity and as all of IntelliJ’s IDE’s, it comes with top-of-the-class refactoring support.

Unfortunately, this does not extend to Unity’s serialised object data and so any refactoring of serialised fields on your MonoBehaviours will break objects that are using that behaviour – both prefabs and objects in your scene.

I’m quite sure it’s perfectly possible to fix this in Riders Unity plugin (IntelliJ: hint hint :)), but until that happens, this is how I deal with it:

Whenever I need to change a public field (or any field marked with [SerializeField]) on a MonoBehaviour that I know is already used on either prefabs or in a scene, I first add the new structure to the MonoBehaviour, then let it implement ISerializationCallbackReceiver. In OnAfterDeserialize, I then add code to copy the data from the old structure onto the new one. When this is done, I switch to Unity, which effectively causes it to reload all prefabs and scenes, applying my transformation code so that I now have the old data in the new structure, and can remove the old structure without losing anything.

For example, let’s say I have made this fantastic behaviour

public class Circle : MonoBehaviour
{
 float angleInRadians;
}

And a level designer calls me up and says: “Radians? WTF dude?”. I guess that means he’d probably prefer to specify this in degrees. I could just change the meaning in the code, but then the name is misleading and existing values will be wrong. So I would like “angleInRadians” to be called “angleInDegrees” – if I just rename it, whatever values are assigned to this in Unity will be lost, so we instead add angleInDegrees as a new field, and let’s make it an integer, just because:

public class Projectile : MonoBehaviour
{
 float angleInRadians;
 int angleInDegrees;
}

Then, let it implement ISerializationCallbackReceiver

public class Projectile :MonoBehaviour,ISerializationCallbackReceiver
{
 float angleInRadians; 
 int angleInDegrees;  
 
 public void OnBeforeSerialize() { }
 public void OnAfterDeserialize() { }
}

And add code to convert the value:

public class Projectile :MonoBehaviour,ISerializationCallbackReceiver
{
 float angleInRadians; 
 int angleInDegrees; 
 
 public void OnBeforeSerialize() { }
 public void OnAfterDeserialize() 
 {
   angleInDegrees = (int)(180*angleInRadians/Mathf.PI);
 }
}

When we return to Unity, it will compile and run the code, updating our new structure with the correct value.

Now, this looks all fine and dandy in the editor, but there’s one little catch – the modifications we made went unnoticed by Unity’s change tracking system, so it does not think the objects has changed and thus will not save them unless we explicitly, and manually, cause it to.

Having to select each object and prefab kind of defies the whole purpose of what we’re trying to do. Ideally we’d want to automatically mark the objects dirty, but we can’t do that during de-serialisation since the APIs for this are off-limits in this context (for obvious reasons), so I have added two small scripts that help me do this:

One is just a static list of objects where I can track all the mutations I make on load:

public class MutatedOnDeserialisation
{
  protected static List<Object> _mutated = new List<Object>();

  public static void Register(Object obj)
  {
    _mutated.Add(obj);
  }
}

The other is an Editor script that extends the one above with a File menu command to traverse the list and mark all the objects as dirty:

public class MutatedOnDeserialisationEditor :MutatedOnDeserialisation
{
  [MenuItem ("File/Mark Mutated Objects")]
  public static void MarkMutated ()
  {
    foreach (var obj in _mutated)
    {
      if (obj)
      {
        EditorUtility.SetDirty(obj);
        Debug.Log("Marked " + obj);
      }
      else
      {
        Debug.Log("Skipping " + obj);
      }
    }
  }    
}

I must admit that I don’t understand why there are null refs in there, but there are – I suspect a combination of temporary objects during load and Unity’s odd boolean operator overload to be playing tricks on me, but as far as I can tell it’s ok to just ignore them.

Also of note: Unity’s documentation says to use Undo.RecordObject() instead of SetDirty(), but for whatever reason, that did not work for me.

In any case, with these two scripts in place, we just need one additional line in the de-serialisation code to make it work, namely the call to register our mutated object:

public void OnAfterDeserialize() 
{
  angleInDegrees = (int)(180*angleInRadians/Mathf.PI); 
  MutatedOnDeserialisation.Register(this); 
}

This is obviously (a lot) more work than simply pressing SHIFT+F6, but it’s also a lot less than having to go through every prefab and object instance in the project and updating it manually, especially in cases that are not as trivial as this one, like changing from a primitive type to a list or struct (and also has no risk of typos or accidentally missing occurrences).

Still, if anyone has a better way of doing this, I would love to hear about it 🙂

 

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s