Programmers are always looking for ways to write safer more bullet proof code. For that reason, at work it was a convention for us to use Unity’s OnValidate() callback to validate the public and serialized properties of our MonoBehaviour components. So for example we would typically write code like:
1 2 3 4 5 6 7 8 9 10 11 |
using UnityEngine; public class BaseBehaviour : MonoBehaviour { [SerializeField] private Transform someTransform = null; private void OnValidate() { Debug.Assert(someTransform != null, "[BaseBehaviour] someTransform property cannot be null", this); } } |
Now, generally speaking, this works great. Essentially, what the above snippet will do is log an error in the Unity console every time the a property of the Monobehaviour is updated, the code is changed (i.e. the scripts reloaded), or until, (the desired goal), the property is set using the Unity Inspector and someTransform is no longer null. This is an excellent way to be sure properties that are meant to be set in the Inspector window are kept to expected parameters (e.g. not null).
Programmers, though, are also very lazy. It wasn’t long before the nice clean assumption above turned into something more like:
1 |
Debug.Assert(someTransform); |
Logically speaking, that works just as well and does the basically the same thing and works just as well (sans the nice output), but it wasn’t long before even that condensed version felt like too much boilerplate code to be worth writing. I wanted something which did the same thing, but was quick and easy with no boilerplate. So I turned to looking at attributes. What I really wanted was to be able to write something like what’s below and skip the OnValidate() call altogether.
1 2 3 4 5 6 |
using UnityEngine; public class BaseBehaviour : MonoBehaviour { [SerializeField, NotNull] private Transform someTransform = null; } |
After a weekend or so of mucking around, I managed to get just that and thought I’d share how here.
The Attributes
We’ll start by creating a base abstract attribute and an abstract subclass attribute that has a Validate() method and an ErrorMessage property to display if the Validate() fails.
1 2 3 4 5 6 7 8 9 10 |
using System; public abstract class OneByOneAttribute : Attribute {} public abstract class ValidationAttribute : OneByOneAttribute { public abstract bool Validate(System.Reflection.FieldInfo field, UnityEngine.Object instance); public abstract string ErrorMessage { get; } } |
The idea being something will call this Validate() method on the attribute, and, if it returns false, will show an error containing the attribute’s ErrorMessage.
Now we’ll create a couple concrete attribute implementations that will cover the two assertions I find myself making the most often – that a property is not null and that an array (or list) property contains x number of elements.
Our NotNull attribute will look like this:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 |
using System; using UnityEngine; [AttributeUsage(AttributeTargets.Field, AllowMultiple = false, Inherited = true)] public class NotNull : ValidationAttribute { public override string ErrorMessage => error; private string error = string.Empty; public override bool Validate(System.Reflection.FieldInfo field, UnityEngine.Object instance) { bool isValid; MonoBehaviour mb = instance as MonoBehaviour; error = $"Property: {field.Name}\non GameObject: {mb.name}\ncannot be NULL"; try { var value = field.GetValue(instance); isValid = !(value.Equals(null)); } catch (Exception) { isValid = false; } return isValid; } } |
One thing to note here is that we want to error message to be as descriptive as possible. One drawback to using attributes for validation rather than just using assertions in OnValidate() is that when we double click the error in the Unity console, we will no longer open the exact spot of the problem. We can mitigate that issue a little though with (a) a detailed error message and (b) by passing a context argument to the Debug.LogError() call – this will mean when we single click on the error message in the console, the context object will be highlighted temporarily in the editor window.
All that in mind, here is a ContainsAtLeast attribute validator:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 |
using System; using System.Collections; using UnityEngine; [AttributeUsage(AttributeTargets.Field, AllowMultiple = false, Inherited = true)] public class ContainsAtLeast : ValidationAttribute { public override string ErrorMessage =>; error; private string error = string.Empty; private int Count; public ContainsAtLeast(int minimumItemCount) { Count = minimumItemCount; } public override bool Validate(System.Reflection.FieldInfo field, UnityEngine.Object instance) { var value = field.GetValue(instance); var mb = instance as MonoBehaviour; var isValid = false; try { error = $"Object: {field.Name}\non GameObject: {mb.name}\nMust contain AT LEAST ({Count}) item(s)"; int i = 0; foreach(var o in (value as IEnumerable)) { i++; if (i >= Count) { isValid = true; break; } } } catch (Exception) { isValid = false; if (typeof(IEnumerable).IsAssignableFrom(field.FieldType)) error = $"Object: {field.Name}\non GameObject: {mb.name}\nMust contain AT LEAST ({Count}) item(s)"; else error = $"Item: {field.Name}\non GameObject: {mb.name}\nMust be Array, List, or String to use attribute properly"; } return isValid; } } |
Also, since developers are lazy, here’s a quick attribute that’s a shortcut to writing ContainsAtLeast(1):
1 2 3 4 5 6 7 8 9 |
using System; [AttributeUsage(AttributeTargets.Field, AllowMultiple = false, Inherited = true)] public class NotEmpty : ContainsAtLeast { public NotEmpty() : base(1) {} } |
The Editor
Next we’ll need the editor script that will actually validate our custom attributes. I won’t bother going too deeply into the code, but generally speaking what we want is an editor that will:
- Work on all Unity Objects
- If there are no fields with custom attributes, just do what a default editor will do
- If there are custom attributes on or more fields, it should validate them and display errors
To more closely replicate the replaced OnValidate() call, it should also:
- Validate all fields on script reload
- Display errors (if there are any) when any property is updated in the editor
All that in mind, here is the final editor script I wrote:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 |
using System; using System.Collections.Generic; using System.Linq; using System.Reflection; using UnityEditor; using UnityEngine; [CanEditMultipleObjects] [CustomEditor(typeof(UnityEngine.Object), true)] public class OneByOneAttributeEditor : Editor { private bool useDefaultInspector = false; private IEnumerable<FieldInfo> fields; private bool shouldShowErrors = true; private void OnEnable() { fields = GetAllFields(target.GetType()); useDefaultInspector = fields.All(f => f.GetCustomAttributes(typeof(OneByOneAttribute), true).Length == 0); } public override void OnInspectorGUI() { if (useDefaultInspector) { base.OnInspectorGUI(); return; } this.serializedObject.Update(); foreach (var f in fields) { ValidateField(f); } shouldShowErrors = this.serializedObject.ApplyModifiedProperties(); } private void ValidateField(FieldInfo field) { var prop = GetSerializedProperty(field); if (prop == null) return; object[] atts = field.GetCustomAttributes(typeof(ValidationAttribute), true); foreach (var att in atts) { ValidateAttribute(att as ValidationAttribute, field); } DrawProperty(prop); } private void DrawProperty(SerializedProperty prop) { EditorGUILayout.PropertyField(prop, true); } private void ValidateAttribute(ValidationAttribute attribute, FieldInfo field) { if (!attribute.Validate(field, this.target)) { EditorGUILayout.HelpBox(attribute.ErrorMessage, MessageType.Error, true); if (shouldShowErrors) ShowError(attribute.ErrorMessage, this.target); } } private SerializedProperty GetSerializedProperty(FieldInfo field) { // Do not display properties marked with HideInInspector attribute object[] hideAtts = field.GetCustomAttributes(typeof(HideInInspector), true); if (hideAtts.Length > 0) return null; return this.serializedObject.FindProperty(field.Name); } public static IEnumerable<FieldInfo> GetAllFields(Type t) { if (t == null) return Enumerable.Empty<FieldInfo>(); BindingFlags flags = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static | BindingFlags.Instance | BindingFlags.DeclaredOnly; return t.GetFields(flags).Concat(GetAllFields(t.BaseType)); } private static void ShowError(string msg, UnityEngine.Object o) { Debug.LogError(msg, o); } [UnityEditor.Callbacks.DidReloadScripts] private static void OnScriptReload() { MonoBehaviour[] behaviours = MonoBehaviour.FindObjectsOfType<MonoBehaviour>(); foreach(var b in behaviours) { var fields = GetAllFields(b.GetType()); foreach(var f in fields) { var atts = f.GetCustomAttributes(typeof(ValidationAttribute), true); foreach(var a in atts) { var vatt = a as ValidationAttribute; if(!vatt.Validate(f, b)) { ShowError(vatt.ErrorMessage, b); } } } } } } |
Drop that script into a folder named Editor, create a test MonoBehaviour component marking some properties with the NotNull, NotEmpty, ContainsAtLeast attribute and check it out in the inspector.
For example, Add this script to a game object named “Custom Attributes” in the scene and this is what you’ll get in the Inspector Window.
And in the Console Window:
Actually add some items to the component via the Inspector and just watch those errors disappear.
Hope this might help out.
Enjoy the holidays, all!
Recent Comments