Вводные данные

Что мы имели на руках:

  • Рабочая ветка 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, так как объектов не существует. Но мы видим в дебаге

Проверка объектов на null
Проверка объектов на null

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
    {
        
    }
}

В дебаге мы получаем очень интересную вещь

Id аниматора - 0, Null ref на Foo классе
Id аниматора - 0, Null ref на Foo классе

Таким образом, делаем вывод:

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());
    }
}

И падаем в первом же вызове

Null ref на аниматоре
Null ref на аниматоре

В данной статье описан подобный механизм для сериализуемых полей, который по всей видимости используется и для 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 только в редакторе.

Итого

  1. Предупреждайте тех артистов, что копировать из Play mode может очень сильно аукнуться, особенно если используете zenject.

  2. Не используйте проверку на null с кастом в object, полученные простым GetComponent - так как built in классы не будут null в эдиторе, используйте либо TryGetComponent либо unity проверку.

  3. Мы создали issue на гитхабе Zenject, может быть в будущих версиях будет поправлено.

Комментарии (16)


  1. Igor_Sib
    16.01.2022 00:38

    Вместо == лучше использовать ReferenceEquals(animator, null)


    1. wththd Автор
      16.01.2022 00:46
      +1

      Да, но речь идёт об Assert в Zenject, который использует ==


    1. KAW
      16.01.2022 13:46

      Или оператор is null или is {}


  1. gatoazul
    16.01.2022 10:45
    -1

    Артисты? У вас там театральный кружок?


    1. Roman_Cherkasov
      16.01.2022 14:46
      -1

      Жаргон же. Не называть же их "Деятель искусств". https://en.wikipedia.org/wiki/Artist


      1. yeswell
        16.01.2022 18:54
        +6

        Художники же. Жаргон жаргоном, но есть устоявшиеся значения слов и это стоит учитывать в статье для достаточно широкого круга читателей


  1. b4vibe
    16.01.2022 19:57

    Совет: сдублировать issue на ветку Mathijs-Bakker, поскольку там хоть что-то происходит по сравнению с основной веткой ZJ.


  1. loltrol
    16.01.2022 22:44
    +1

    Я, как человек пришедший з java-backend на unity c#, просто офигеваю с того, что сделали с c# рантаймом в unity3d. Начиная с костыльных компонентов с их event методами, заканчивая странным api с множеством статических классов.

    Хочется сделать по ентерпрайзнутым понятиям, там юнит тесты, авто-тесты наклепать, а фигушки - палки будут вставлены везде :)


    1. robo2k
      16.01.2022 23:14

      Юнит-тесты в играх особо не повыставляешь, потому что большая часть логики зависит от прямого контроля пользователя либо сложно эмулируема

      Статик классы вполне себе нормально работают, ведь всегда легче подтянуть статик метод чем каждый раз создавать инстанс нужного класса.

      Все таки энтерпрайзная жава и игровые проекты это разные вещи


      1. loltrol
        16.01.2022 23:56

        Зависит от условий и проекта. На unity3d не только игры делают с минимумом сырой логики. У меня на проекте в основном юайка(динамическая, строится по внешней схеме), которую с горем пополам удалось покрыть нормально тестами, и теперь кнопки, при очередном изменении чего либо, никуда не плывут.

        А статические классы в совокупности с domain reload творят чудеса в умелых руках. Чудеса с текущей памятью в умелых руках в editor mode.

        Вообщем я не критикую подходы, которые используются в геймдеве(и в часности в unity3d), но они требуют некоторой адаптации(моральной, в большинстве случаев)


        1. robo2k
          17.01.2022 09:51
          +1

          А можно узнать что это вы такое там делаете? Хотя бы жанр, но если можно то и подробней.


  1. robo2k
    16.01.2022 23:19

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


    1. Lailore
      17.01.2022 11:44

      Что вы подразумеваете под неявной логикой?


      1. robo2k
        17.01.2022 13:37

        Вызовы которые невозможно нормально отследить по стеку и зависимостям в идешке.


        1. Lailore
          18.01.2022 18:46

          Все вызовы ищутся по usage, по крайней мере в rider. Так же если вы пользуетесь интерфейсами, там есть ссылки на реализации.

          Что значит не нормально? Можете привести пример?


  1. AleVerDes
    17.01.2022 15:51
    -1

    Для любых UnityEngine.Object (наследников MonoBehaviour, Behaviour, UnityEngine.Component или UnityEngine.Object) лучше использовать их собственный impilicit operator bool (т.е. простое приведение к bool). Там более корректная проверка на существование объекта, включающая и null-проверку, и то, что С++ объект данной C# оболочки жив.