Pitfalls of Zenject or the secret world of Unity GetComponent

Input data

What we had on hand:

  • The develop branch is healthy and running on the device.

  • A branch of those artists where they work on a big feature with modified prefabs for a couple of months. It works in the editor, but crashes on the device when creating a prefab.

  • Those artists added some scripts and some components that weren’t there before.

  • The project uses Zenject.

Build error when creating prefab
Build error when creating prefab

Examination of the patient

After the first research, we realized that in some components there were no references to the necessary objects – this was throwing Null Ref. With a sense of accomplishment, we sent the prefabs for revision (they also added a validator to avoid mistakes in the future) in the hope that everything will be fixed. But the patient came back with the same error.

Then they began to look at il2cpp stripping. As we know, unused components, scripts outside of namespaces and others are cut out during assembly. After changing the build to mono, the error remained, which means that the problem was not this.

Fight for life

After all the above actions, the mood changed dramatically from “yes, what is there to fix” to “what is happening at all”. Where can there be Null if

  • Everything works in the editor.

  • The build is going on mono, that is, nothing is cut out

We connect heavy artillery in the form of LogCat and Debug.Log.

In Logcat we see an error:

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

This error doesn’t help much. Yes, Assert.IsNotNull crashes somewhere, but where exactly and why remains a mystery. We dig even deeper with the logs and stumble upon that it turns out that the error is thrown from

at Zenject.ZenjectStateMachineBehaviourAutoInjecter.Construct

Treatment

After localizing the problem, it is worth looking at what kind of auto-injector it is and what kind of method it is.

[Inject]
public void Construct(DiContainer container)
{
    _container = container;
    _animator = GetComponent<Animator>();
    Assert.IsNotNull(_animator);
}

It seems that everything is clear: this script is added to the object with the animator in the runtime zenject and then it is checked that the animator really exists. But how can this code work in the editor, but not on the device? Let’s look at the internals of the Assert class

public static void IsNotNull(object val)
{
    if (val == null)
    {
        throw CreateException("Assert Hit! Found null pointer when value was expected");
    }
}

At first glance, nothing interesting either, a regular C# object is compared with null, everything should work. A simple prefab search showed that there are objects with ZenjectStateMachineBehaviourAutoInjecterbut no animator. It was because of this that the build fell.

We found out that those artists changed a lot of objects at runtime, then copied them and pasted them into the prefab itself in the editor. In the course of work, some animators were removed and, accordingly, an auto-injector error was thrown on the device that it could not find the required component.

Naturally, all auto-injectors were removed, the build began to work, those artists were warned, it would seem that the ticket was closed, everyone was happy, but there was one snag.

Why does it work in the editor?

The wonders of Unity GetComponent and Behavior

The most interesting thing in solving problems is understanding their causes, which is what we actually decided to do. Why the check above does not fail in the editor, but it fails during the build.

Let’s test!

The first test we did was what happens when the component is checked and cast into an object. Naturally, this test script is thrown at an empty 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
    {
        
    }
}

It is logical to expect that everywhere there will be true, since the objects do not exist. But we see in the debug

Checking objects for null
Checking objects for null

true, false, true, true. Whence in the second check False? Why if you run the same code on the phone, all four debugs will true?

Let’s look at the animator himself.

Its structure looks like this:

Animator -> Behavior -> Component -> Object

In fact, this does not give us anything, since the empty Monobehavior class looks like this

Foo -> Monobehaviour -> Behavior -> Component -> Object

But at the same time, the behavior of the animator and the class is different. So the problem is most likely with the GetComponent method. From documentation we do not see anything that could suggest thoughts, but it is already obvious that the method GetComponent on the built-in classes works differently. It is very easy to test this theory:

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

In debugging we get a very interesting thing

Animator id - 0, Null ref on Foo class
Animator id – 0, Null ref on Foo class

Thus, we conclude:

GetComponent on unity classes does not return null, but null object, so all Zenject Assert with Unity components will not work in the editor.

You can also call Destroy(animator) and there will be no errors.

After that, we stumbled upon an interesting point, which is described in the documentation for the method TryGetComponent. It says:

The notable difference compared to GameObject.GetComponent is that this method does not allocate in the Editor when the requested component does not exist.

Thus, we modify our test script to confirm the theory:

void Start()
{
    TryGetComponent(out Animator animator);
    if (animator == null) {
      Debug.Log(animator.GetInstanceID());
    }

    TryGetComponent(out Foo foo);
    if (foo == null) {
      Debug.Log(foo.GetInstanceID());
    }
}

And we fall in the first call

Null ref on animator
Null ref on animator

In this article a similar mechanism is described for serializable fields, which is apparently used for built in components.

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.

Thus, we found out that GetComponent for built in classes returns a reference not to a real null object, but to a special fake null object, which, when cast to object and compared to null, will return False only in the editor.

Total

  1. Warn those artists that copying from Play mode can come back to haunt you, especially if you use zenject.

  2. Don’t use a cast null check on the object obtained by a simple GetComponent – since built in classes won’t be null in the editor, use either TryGetComponent or unity checks.

  3. We created issue on the Zenject github, maybe it will be fixed in future versions.

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *