Валидация ассетов в Unity3D

от автора

Начнём с того, что я обожаю сериализацию в Unity. Она надёжна и очень проста в использовании. Я просто расширяю MonoBehaviour, ScriptableObject и подобные классы и настраиваю сериализуемые поля экземпляров в инспекторе.

Но у неё есть и слабости. Одна из них ― человеческий фактор. Представьте себе огромный проект, который живёт несколько лет и над которым работает около сотни человек. И любой из них может совершить ошибку: оставить пустую ссылку на объект, указать число вне диапазона, ввести строку в неверном формате, заполнить массив слишком маленьким или, наоборот, слишком большим количеством объектов. Уверен, у каждого из вас найдутся такие примеры из своего опыта. Причин и оправданий тоже множество: невнимательность, неожиданные последствия слияния веток, сбои редактора… И никто от этого не застрахован.

Такие ошибки до поры до времени остаются незаметными: компилятору до них нет дела, в отличие от опечаток в коде. Особенно неприятны они тем, что проявляются часто уже во время выполнения кода. Только тогда вы начинаете читать журнал сообщений и идёте проверять данные: тыкать их в редакторе или листать YAML. Но объектов может быть достаточно много, есть риск что-то пропустить или попросту залениться.

Конечно, можно добавить проверок в коде, но от этого он загрязнится. Иногда эти проверки негативно влияют на производительность. А ещё не всегда однозначно понятно, как именно обработать каждую конкретную ошибку.

Универсального или даже штатного метода бороться с подобным в Unity нет. Поэтому мы в Pixonic реализовали свою систему валидации ассетов. И это очень помогает нам жить.

Сейчас я опишу, как там всё устроено.

Работает система на основе атрибутов на сериализуемых полях. Сначала тот, кто пишет скрипт, с помощью атрибута явно указывает свои ожидания от значения поля. Самый частый пример ― ExpectNotNull.

public class LookAtTarget : MonoBehaviour {   [SerializeField, ExpectNotNull] private Transform target;    // ... }

При постобработке ассетов значения в таких полях будут проверены на соответствие ожиданиям. В обычном режиме при сохранении ассета в логе можно увидеть нарушения с подробной локализацией, а при клике на такой лог объект подсвечивается в редакторе:

[Error] Reference is null  Property: target  Attribute: ExpectNotNullAttribute  Script: Game.Core.LookAtTarget  Object: Turret  Asset: Assets/Scripts/Game/Test.unity

В режиме batch mode у нас собирается отдельный отчёт обо всех нарушениях. Опционально существует возможность завершить сборку в CI (Continuous Integration) неудачно, если хотя бы одно ожидание с Severity.Error не сработало.

Именно эта деталь даёт нам хорошие (но не стопроцентные!) гарантии того, что данным можно доверять, и проверять их дополнительно, скорее всего, не следует.

Вроде бы этого должно быть достаточно. Но бывает такое, что в редакторе закрыта панель лога, или в ней стоит фильтр, из-за чего человек лог не видит. Поэтому для того, чтобы разработчик сразу при настройке понимал, что нарушает ожидание, была сделана система уведомлений прямо в инспекторе:

Система расширяема, и любой может добавить свой тип ожидания, написав атрибут и валидатор к нему. У нас реализовано несколько таких типов и иногда добавляются новые: как общие, так и проектно-специфичные.

Пример атрибута для валидации длины строки:

public class ExpectStringLengthAttribute : ExpectationWithSeverityAttribute {   public readonly int Min;   public readonly int Max;    public ExpectStringLengthAttribute(int min, int max) => (Min, Max) = (min, max); }

И сам валидатор длины строки:

[Validator(typeof(ExpectStringLengthAttribute))] public class ExpectStringLengthValidator : IValidator {   void IValidator.Validate(SerializedProperty property, ExpectationAttribute attribute, IList<Issue> output)   {  	if (property.propertyType != SerializedPropertyType.String)  	{     	output.AddTypeNotSupported(attribute, property.type);     	return;  	}   	var length = property.stringValue.Length;  	var rangeAttribute = (ExpectStringLengthAttribute) attribute;  	if (length < rangeAttribute.Min || length > rangeAttribute.Max)  	{     	output.Add(        	rangeAttribute.Severity,        	attribute,        	"Length out of range [{0}, {1}]", rangeAttribute.Min, rangeAttribute.Max     	);  	}   } }

Для чего нужны эти два класса? Класс атрибута должен находиться в сборке с основным кодом игры: это просто метка с параметрами. Код класса валидатора запускается только в контексте редактора и поэтому должен находиться в соответствующей сборке (в поддиректории Editor). После загрузки скриптов в контексте редактора мы обходим сборки и составляем статический словарь с ключами в виде типов атрибутов и значениями в виде экземпляров валидатора.

[DidReloadScripts] public static void ReloadScripts() {   var validatorType = typeof(IValidator);   _validators.Clear();    foreach (var assembly in AppDomain.CurrentDomain.GetAssemblies())   {  	foreach (var type in assembly.GetTypes())  	{     	if (type == validatorType || !validatorType.IsAssignableFrom(type))     	{        	continue;     	}      	foreach (var attribute in type.GetCustomAttributes(typeof(ValidatorAttribute), true))     	{        	_validators.Add(           	((ValidatorAttribute) attribute).AttributeType,           	(IValidator) Activator.CreateInstance(type)        	);     	}  	}   } }

Этот словарь далее используется как в постобработке, так и при отрисовке инспектора.

public static void Validate(SerializedProperty property, FieldInfo fieldInfo, IList<Issue> output) {   var attributes = fieldInfo.GetCustomAttributes(typeof(ExpectationAttribute), true);   for (int i = 0, count = attributes.Length; i != count; ++i)   {  	var propertyAttribute = (ExpectationAttribute) attributes[i];   	if (_validators.TryGetValue(propertyAttribute.GetType(), out var validator))  	{     	validator.Validate(property, propertyAttribute, output);  	}   } }

Во время отрисовки инспектора вызвать валидацию легче, так как в PropertyDrawer сразу доступны и property, и fieldInfo. Во время постобработки дело обстоит сложнее, так как обходить всё сериализуемое дерево приходится вручную, параллельно через рефлексию собирая FieldInfo для каждого SerializedProperty. Это достаточно объёмный код, поэтому я не буду добавлять его в статью, но здесь можно посмотреть, как это делается.

В иных сферах подобная валидация будет слишком слабой защитой от непредвиденных ошибок, но для наших целей такой способ вынести некоторые проверки из этапа времени выполнения на этапы редактирования и сборки вполне себя оправдывает. Это ускоряет разработку уже хотя бы тем, что мы можем точно увидеть, где и кто что-то сломал, снижая влияние человеческого фактора на качество сборки.

ссылка на оригинал статьи https://habr.com/ru/company/pixonic/blog/491324/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *