Краткое вступление
Как правило, для того чтобы добраться до интересующего нас поля сериализованного свойства, мануальная терапия советует нам использовать метод FindPropertyRelative(), в который прокидывается название переменной.
По определённым причинам такой подход не всегда удобен. Причины могут быть самыми разнообразными. Например, название переменной может смениться, нам кровь из носу нужен доступ к несериализованному свойству, нам необходимо иметь доступ к геттерам-сеттерам или вообще методам сериализованного объекта. Не будем задавать вопросов «зачем вам это вообще нужно» и «почему вы не могли обойтись традиционными путями». Положим, нужно – и всё тут.
Итак, давайте же разберёмся как из сериализованного свойства получить объект, с которым мы работаем, а также все его родительские объекты, и не навернуться по дороге сериализации, полной подводных камней.
Внимание. Данная статья подразумевает, что вы уже умеете работать с UnityEditor’ом, хотя бы раз писали кастомные PropertyDrawer’ы и хотя бы в общих чертах понимаете, чем сериализованное свойство отличается от сериализованного объекта.
Путь сериализации
Для начала расставим все точки над O.
В самом примитивном случае у нас есть некий класс-наследник от MonoBehaviour, и у него есть некое поле, принадлежащее сериализованному классу, очевидно не являющемуся наследником от священной юнитевской коровы а.к.а. UnityEngine.Object.
public class ExamplePulsar : MonoBehaviour
{
...
[Space][Header("Example Sandbox Inspector Field")]
public SandboxField sandboxField;
...
}
В коде выше SandboxField – это класс с аттрибутом Serializable.
Получить доступ к хозяйскому MonoBehaviour’у не составляет проблем:
UnityEngine.Object serializationRoot = property.serializedObject.targetObject;
По желанию можно взять его через as, но нам сейчас это не нужно. Нас интересует само сериализованное поле, чтобы его отрисовать со всем блекджеком как на рисунке ниже.

Путь сериализации мы можем взять следующим образом:
string serializationPath = property.propertyPath;
В нашем случае путь сериализации будет состоять из одного нашего поля и вернёт “sandboxField”, от которого нам пока что ни холодно, ни жарко, поскольку для первого уровня вложенности нам необходимо знать только имя переменной (которое, впрочем, нам и вернули).
Обратите внимание, родительского MonoBehaviour в пути нет. Сейчас это неважно, но станет важно, когда мы начнём разбирать матрёшку, выглядящую примерно вот так:
nestedClassVariable.exampleSandboxesList.Array.data[0].evenMoreNested.Array.data[0]Чтобы не навернуться впоследствии, когда свойства будут вложенными, загодя сделаем следующее:
string[] path = property.propertyPath.Split('.');
Теперь мы имеем все узлы пути сериализации. Но в самом примитивном случае нам нужен только нулевой узел. Возьмём его:
string pathNode = path[0];
Включим немного рефлексии и получим отсюда само поле:
Type objectType = serializationRoot.GetType();
FieldInfo objectFieldInfo = objectType.GetField(pathNode);
object field = objectFieldInfo.GetValue(serializationRoot);
Оставим за кадром вопрос быстродействия этой затеи. Для небольшого количества таких полей и малой вложенности затраты на рефлексию будут существенно меньше, чем на всё то, что происходит под капотом UnityEditor’a за время отрисовки. Если хотите пруфов – на гитхабе у разрабов Unity есть такая интересная штука, UnityCsReference, посмотрите на досуге, как реализована отрисовка ObjectField, например.
Собственно, на этом
По крайней мере, пока оно висит в корне этого объекта, всё будет хорошо, а вот дальше – уже не очень. Вниз по пути сериализации нас ждут массивы и всякие списки, чьё главное желание – подгадить нам в тапки, своевременно изменив количество элементов в большую сторону. Но чёрт бы с этим, нам бы сначала подкопаться под сам массив.
Наша цель – устойчивость к вот таким матрёшкам

Если бы у нас в пути сериализации не было массивов, задача решалась бы тривиально: мы бы в цикле перебирали узлы сериализации, пока не дошли бы до конца цепочки. Что-то вроде нижеследующего кода:
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;
Здесь у нас ждут сразу две неприятные новости. Начну со второй.
Шаг со взятием nextObject может внезапно нам вернуть null вместо ожидаемого объекта. Обычно это случается, когда мы впервые создаём родительский объект в инспекторе, и путь сериализации уже существует, а соответствующее ему поле – нет (это нам ещё дальше доставит приятностей).
На этот случай нам неплохо бы сразу дописать выход из метода с возвращением null:
object nextObject = objectFieldInfo.GetValue(currentObject);
if (nextObject == null) return null;
currentObject = nextObject;
«Погодите! – скажете вы. – А что делать тогда в OnGUI, если нам вернули нуль?»
Ответ: ничего. В буквальном смысле ничего. Просто сделать return и таким образом пропустить этот шаг отрисовки, дождавшись, пока поле создастся. Ничего страшного от этого не произойдёт.
public override void OnGUI(Rect position, SerializedProperty property, GUIContent label)
{
SandboxField sandboxField = GetTarget<T>(property);
if (sandboxField == null) return;
…
}
здесь GetTarget() – соответствующая функция, берущая сериализованный объект из проперти.
Кстати, я посоветую брать интересующее нас поле не здесь, а в GetPropertyHeight. Это понадобится на тот случай, если мы будем писать сворачивающиеся-разворачивающиеся поля с разным размером в зависимости от содержимого. GetPropertyHeight() вызывается до OnGUI(), так что если мы возьмём поле там и запишем его в поле нашего PropertyDrawer’a, то нам не придётся брать его повторно в OnGUI.
Обратите внимание, что экземпляр кастомного PropertyDrawer’a создается один для отрисовки всех в данный момент видимых сериализованных свойств, и в него по очереди сверху-вниз кидаются новые свойства. Это стоит учесть, чтобы не напортачить с вычислением высоты очередного свойства, иначе можете получить неприятную ситуацию, когда у вас по нажатию на foldout разворачивается не то поле, которое вы ожидаете.
Также всю мишуру, которая отвечает за отображение поля в редакторе и которую вы хотите сериализовать, сериализовать вам стоит на стороне сериализуемого класса, а не PropertyDrawer’a, а для верности – обнести скобками условной компиляции, чтобы весь этот испанский стыд не попытался пойти в билд:
[Serializable]
public class SandboxField
{
…
#if UNITY_EDITOR
public bool editorFoldout;
#endif
…
}
Ещё один подводный камень, который нас здесь поджидает: все созданные через эдитор поля плевать хотели на конструктор класса и на значения по умолчанию, заданные в классе. Если вы сделаете, например, вот так (пример из моего проекта, где это было значение узлов водяной поверхности):
[SerializeField]private int m_nodesPerUnit = 5;
Это значение будет проигнорировано сериализацией в упор, как только вы добавите новый элемент в список. Звать на помощь конструктор не менее бесполезно: всё, что вы там понаписали, будет проигнорировано. Новый объект гол как сокол, все его значения – действительно значения по умолчанию, вот только не те, которые вы хотите там увидеть, а всякие null, false, 0, Color.clear и прочие непотребные вещи.
Костыль в мясо
Есть тривиальный костыль. Создаём класс NonUnitySerializableClass, от которого наследуем все наши сериализованные плюшки. В самом классе делаем виртуальную функцию, DefaultEditorObject(), которую при необходимости перегружаем.
Дальше пишем что-то вроде:
В дальнейшем просто переписываем по необходимости для наследников DefaultEditorObject(), чтобы задать дефолтные значения, а к EditorCreated стучимся при валидации полученного поля.
Результат починки: адекватная обработка значений только что созданного объекта.

Дальше пишем что-то вроде:
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 стучимся при валидации полученного поля.
Результат починки: адекватная обработка значений только что созданного объекта.

Вернёмся к нашим баранам, а точнее – массивам. Другая проблема, которая может возникнуть на ещё более раннем этапе, кроется вот в этой строчке:
FieldInfo objectFieldInfo = objectType.GetField(pathNode);
Правая часть может вернуть нам нуль, если напорется на массив в пути сериализации (причём массивом “Array” будет любой объект IList). Неприятно.
Что делать?
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;
Да, мы можем даже здесь впаяться в неприятную ситуацию, когда путь сериализации уже имеет, например, элемент data[0] или data[1], а массив его ещё не реализовал. Например, мы создали пустой список List. Мы задаём ему N элементов – и без этой красивой строчки:
if (collection.Count <= arrayNumber) return null;
…получаем кучу исключений в мурчало. А всего-то надо было пропустить шаг отрисовки, дождавшись, пока будут созданы интересующие нас поля.
Мне пока не попадались другие случаи, когда objectFieldInfo == null, но при этом узел сериализации не обозначен как Array, поэтому на такую гипотетическую исключительную ситуацию стоит выкидывание страшного исключения – чтобы впоследствии его расколупать.
В общем и целом, мы получили более-менее рабочую функцию, позволяющую извлечь поле по его сериализованному свойству. В дальнейшем эту функцию можно модифицировать, заставив извлекать все объекты в пути сериализации, а также искать ближайшего «родителя», включая или исключая массивы по пути.
Лайфхак для отрисовки вложенных свойств
Чтобы отрисовать свойства с учётом смещения из-за вложенности списка, можно вместо Rect position использовать Rect indentedPosition = EditorGUI.IndentedRect(position). Обратите внимание только на то, что если вы будете рисовать внутренние поля через EditorGUI, вам необходимо будет использовать исходный position свойства, а если вы будете рисовать через GUI – тогда вам понадобится indentedPosition. Не используйте EditorGUILayout внутри OnGUI, ничего хорошего из этого не выйдет (чаще всего не выходит, во всяком случае).
Если вам необходимо рисовать поле, ссылающееся на MonoScript соответствующего класса (класса самого сериализуемого объекта или класса-рисовашки, не суть важно), желательно кешировать его в static-поле, чтобы не лезть постоянно в AssetDatabase, поскольку это затратно по ресурсам и при очень большом количестве свойств может привести к заметным тормозам редактора.
Если вам необходимо рисовать поле, ссылающееся на MonoScript соответствующего класса (класса самого сериализуемого объекта или класса-рисовашки, не суть важно), желательно кешировать его в static-поле, чтобы не лезть постоянно в AssetDatabase, поскольку это затратно по ресурсам и при очень большом количестве свойств может привести к заметным тормозам редактора.
Спасибо за внимание.