Unity is a great engine for multi-platform development, but as with all engines, it certainly has its weak spots. In an attempt for simplicity, the folks at Unity seem to have erred on the side of less control when it comes to how objects are initialized. This lack of control may be acceptable for smaller demo projects or hobbyists, but for those of us working on larger scale development, the initialization itself quickly becomes a roadblock and/or a source of a large number of bugs and problems. I’ll attempt to go through some of the challenges we ran into very early on.
The Unity object life cycle is rather simple, it only gets 2 calls at object creation time. The first, is the Awake call. This call happens when an object an object is instantiated, and happens only once during its lifetime. The second call is Start, which also only happens once after instantiation. Unity guarantees that the Awake will be called on *all* objects in a particular frame before the Start gets called on all the objects. The general idea is that you set up all your cross object references in the Awake, and then do all the initialization of actual data in the Start (at which point, in theory, Awake will have been called on every object already). At first glance, although limiting, isn’t a complete disaster. As your project increases in complexity there are a number of issues that become impossible to resolve rather quickly. First, the order in which objects get their Awake and Start calls happen is random. It’s not completely random, it is semi-predictable when you start working, but as you update platforms or Unity versions, you’ll see the ordering change. It’s just enough to fool you into bad habits. This means that for any cross reference between two objects, they both need to have special case code in their Awake to handle the possibility that the other object has not yet been created. For example, let’s say object A has a reference to object B, and object B has a reference to object A. Now when A is initialized, it has to look to see if object B exists yet, and if it does, it would set the reference to object B. If it’s not created yet, it would do nothing. Object B, when it’s initialized does the same check, looks to see if it can find Object A, and if does, it sets the reference to object A. The problem comes in that whichever object is initialized first will not get a reference to the other object, only the second one will. This can be solved by having Object A check if B exists, and if so, set the reference to B, and then reach into Object B and set the reference back to itself (so that B will have it as well). Immediately, this breaks the loose coupling that you’re supposed to have in OO programming, with each object needing to know about the internals of the other. This is a rather small example, but as a project grows, you can imagine how the scale of the code grows.
This problem, although leading to ugly code, is solvable, but assuming you’re willing to put up with it, there’s a second issue that you’ll quickly run into. Objects that are loaded via scene vs instantation vs pre-fab all have different latencies after which their Awake/Start is called. So in the case of having your objects created at runtime, or loaded through different methods, now your cross references break again. This means that you can’t even guarantee that the awake will be called before the start in 2 objects that are loaded via a scene vs an asset bundle.
This issue stretches beyond the initialization. On each frame, every object gets an Update call. When they are destroyed, they’ll get an OnDestroy. The order of the OnDestroy calls can be somewhat haphazard, and isn’t guaranteed to happen on the same frame. So take our previous example, and expand it to include any reference in one object to the other in its Update. And go one beyond that to include any function that can be called from any other object that has an Update.
So the solution? The only viable solution that we were able to come up that still satisfied the other requirements of our project, was to not use the Awake or Start callbacks. In our case, we set up our own analogous initialization system, where the order of initialization was strictly controlled. We were vigilant to not fall into old habits and use the Awake/Start calls, and once we stopped using them our lives became a lot easier. I’ll post a bit more about our solution in a later post.