Вводные данные
Что мы имели на руках:
Рабочая ветка develop исправна и работает на устройстве.
Ветка тех артистов, на которой они пару месяцев работают над большой фичей с измененными префабами. Она работает в редакторе, но падает на устройстве при создании префаба.
Тех артисты добавили несколько скриптов и несколько компонентов, которых не было раньше.
На проекте используется Zenject.
Осмотр больного
После первого ресерча мы поняли, что в некоторых компонентах отсутствовали ссылки на нужные объекты - это и кидало Null Ref. С чувством выполненного долга отправили префабы на доработку (еще добавили валидатор, чтобы избежать ошибок в будущем) в надежде, что все исправится. Но пациент вернулся обратно с той же ошибкой.
Следом стали смотреть на стриппинг il2cpp. Как мы знаем, при сборке вырезается множется неиспользуемых компонентов, скрипты вне неймспейсов и другие. После смены билда на mono ошибка осталась, а значит проблема была не этом.
Борьба за жизнь
После всех вышеперечисленных действий настроение резко поменялось с «да что там исправлять» на «что вообще происходит». Откуда там может быть Null, если
В редакторе все работает.
Билд собирается на mono, то есть ничего не вырезается
Подключаем тяжелую артиллерию в виде LogCat и Debug.Log.
В Logcat мы видим ошибку:
Assert Hit! Found null pointer when value was expected
at ModestTree.Assert.IsNotNull (System.Object val) [0x00000] in <00000000000000000000000000000000>:0
at System.Reflection.MonoMethod.Invoke (System.Object obj, System.Reflection.BindingFlags invokeAttr, System.Reflection.Binder binder, System.Object[] parameters, System.Globalization.CultureInfo culture) [0x00000] in <00000000000000000000000000000000>:0
at Zenject.ZenInjectMethod.Invoke (System.Object obj, System.Object[] args) [0x00000] in <00000000000000000000000000000000>:0
at Zenject.DiContainer.CallInjectMethodsTopDown (System.Object injectable, System.Type injectableType, Zenject.InjectTypeInfo typeInfo, System.Collections.Generic.List`1[T] extraArgs, Zenject.InjectContext context, System.Object concreteIdentifier, System.Boolean isDryRun) [0x00000] in <00000000000000000000000000000000>:0
Эта ошибка не особо помогает. Да, где-то вылетает Assert.IsNotNull, но где именно и почему остается загадкой. Копаем еще глубже с логами и натыкаемся, что оказывается ошибка кидается из
at Zenject.ZenjectStateMachineBehaviourAutoInjecter.Construct
Лечение
После локализации проблемы стоит посмотреть, что это за автоинжектор и что за метод.
[Inject]
public void Construct(DiContainer container)
{
_container = container;
_animator = GetComponent<Animator>();
Assert.IsNotNull(_animator);
}
Вроде бы все понятно: на объект с аниматором в рантайме зенжектом добавляется этот скрипт и далее проверяется, что аниматор действительно есть. Но разве может этот код работать в редакторе, но не работать на девайсе? Посмотрим на внутренности класса Assert
public static void IsNotNull(object val)
{
if (val == null)
{
throw CreateException("Assert Hit! Found null pointer when value was expected");
}
}
На первый взгляд тоже ничего интересного, обычный С# объект сравнивается с null, все должно работать. Простой поиск по префабу показал, что существуют объекты, с ZenjectStateMachineBehaviourAutoInjecter, но нет аниматора. Именно из-за этого и падал билд.
Выяснили, что тех артисты изменяли много объектов в рантайме, потом копировали их и вставляли уже в редакторе в сам префаб. В ходе работы некоторые аниматоры были удалены и соответственно на девайсе кидалась ошибка автоинжектора, что он не смог найти нужный компонент.
Естественно, все автоинжекторы были удалены, билд стал работать, тех артисты предупреждены, казалось бы, тикет закрыт, все рады, но осталась одна загвоздка.
Почему это работает в редакторе?
Чудеса Unity GetComponent и Behaviour
Самое интересное в решении проблем - это понимание их причин, чем мы собственно и решили заняться. Почему проверка выше не падает в редакторе, но при этом падает при билде.
Протестируем!
Первый тест, который мы провели - что происходит при проверке компонента и касте в объект. Естественно, этот тестовый скрипт кидается на пустой GameObject.
using UnityEngine;
public class Test : MonoBehaviour
{
// Start is called before the first frame update
void Start()
{
var animator = GetComponent<Animator>();
var foo = GetComponent<Foo>();
Debug.Log(animator == null);
Debug.Log((object)animator == null);
Debug.Log(foo == null);
Debug.Log((object)foo == null);
}
public class Foo : MonoBehaviour
{
}
}
Логично ожидать, что везде будет true, так как объектов не существует. Но мы видим в дебаге
True, False, True, True. Откуда во второй проверке False? Почему если запустить этот же самый код на телефоне все четыре дебага будут true?
Посмотрим на сам аниматор.
Его структура выглядит так:
Animator -> Behaviour -> Component -> Object
По сути это нам ничего не дает, так как пустой Monobehaviour класс выглядит также
Foo -> Monobehaviour -> Behaviour -> Component -> Object
Но при этом поведение аниматора и класса разное. Значит проблема скорее всего с методом GetComponent. Из документации мы не видим ничего, что могло бы натолкнуть на мысли, но уже очевидно, что метод GetComponent на built-in классах работает иначе. Проверить эту теорию очень просто:
using UnityEngine;
public class Test : MonoBehaviour
{
// Start is called before the first frame update
void Start()
{
var animator = GetComponent<Animator>();
var foo = GetComponent<Foo>();
if (animator == null) {
Debug.Log(animator.GetInstanceID());
}
if (foo == null) {
Debug.Log(foo.GetInstanceID());
}
}
public class Foo : MonoBehaviour
{
}
}
В дебаге мы получаем очень интересную вещь
Таким образом, делаем вывод:
GetComponent на unity-классах возвращает не null, а null object, поэтому все Zenject Assert с Unity компонентами не будут работать в редакторе.
Также можно вызывать Destroy(animator) и не будет никаких ошибок.
После этого мы наткнулись на интересный момент, который описан в документации по методу TryGetComponent. Там сказано:
The notable difference compared to GameObject.GetComponent is that this method does not allocate in the Editor when the requested component does not exist.
Таким образом модифицируем наш тестовый скрипт, чтобы подтвердить теорию:
void Start()
{
TryGetComponent(out Animator animator);
if (animator == null) {
Debug.Log(animator.GetInstanceID());
}
TryGetComponent(out Foo foo);
if (foo == null) {
Debug.Log(foo.GetInstanceID());
}
}
И падаем в первом же вызове
В данной статье описан подобный механизм для сериализуемых полей, который по всей видимости используется и для built in компонентов.
When a MonoBehaviour has fields, in the editor only[1], we do not set those fields to "real null", but to a "fake null" object. Our custom == operator is able to check if something is one of these fake null objects, and behaves accordingly.
Таким образом, мы выяснили, что GetComponent для built in классов возвращает ссылку не на настоящий null объект, а на специальный fake null object, который при касте в object и сравнении на null будет возвращать False только в редакторе.
Итого
Предупреждайте тех артистов, что копировать из Play mode может очень сильно аукнуться, особенно если используете zenject.
Не используйте проверку на null с кастом в object, полученные простым GetComponent - так как built in классы не будут null в эдиторе, используйте либо TryGetComponent либо unity проверку.
Мы создали issue на гитхабе Zenject, может быть в будущих версиях будет поправлено.